ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

嵌入式Flash存储方案:SFUD+FAL+FlashDB在W25Q64上的掉电安全实践

嵌入式Flash存储方案:SFUD+FAL+FlashDB在W25Q64上的掉电安全实践 简介面向嵌入式开发者的中移物联万耦开发板实际测试例程围绕STM32的QSPI接口驱动W25Q64这款Flash芯片集成了FlashDB轻量级数据库、FAL分区管理以及SFUD通用驱动框架。例程通过完整工程演示了数据分区划分、键值与时序数据的存储和读取流程能够帮助读者减少对Flash底层的直接操作降低写入复杂度同时借助均衡写入等机制延长芯片工作寿命。整个资源共包含2009个文件以850个C源码和576个头文件为主体辅以Markdown笔记、PNG原理图、链接脚本以及工程配置文件等压缩包约50.6MB结构清晰适合直接对照移植或二次开发。压缩包中还包含Keil与IAR工程文件以及可执行固件便于直接打开验证。当前已有1225人学习浏览尤其适合正在着手FlashDB移植或希望系统掌握FAL与SFUD组合使用方法的嵌入式开发者。 前阵子给中移物联万耦开发板的项目做参数存储遇到一个挺烦的事设备有几个校准时标、阈值和序列号要持久化断电重启后不能丢。之前我习惯直接拿SPI读写W25Q64自己维护地址分配表后来发现参数一多这套土办法越来越难受——要在应用层处理扇区擦除、磨损均衡、掉电中断保护一个不留神就把整片Flash搞乱。折腾了两周最后换成了SFUDFALFlashDB这套组合拳在万耦开发板上跑通了实际测试例程。这篇文章就把完整的移植过程、配置代码和掉电测试结果记录下来想用W25Q64做掉电不丢失存储的朋友可以参考一下。1. 为什么是SFUDFALFlashDB这套组合1.1 直接操作W25Q64的痛点W25Q64是64Mbit的SPI NOR Flash断电后数据不丢听起来很简单实际用起来限制不少。第一个问题是必须先擦后写而且擦除最小单位是一个扇区4KB不能像内存一样随意按字节修改。第二个问题是磨损均衡NOR Flash的擦写寿命通常在一万到十万次之间如果参数反复写同一个扇区很快就把那一块写坏。第三个问题是掉电保护如果正在擦除或者写入的中途断电Flash里可能是半旧半新的数据严重时整个扇区被擦成全FF。我之前就吃过一次亏。设备在更新校准时标的瞬间断电重启后参数区读出来全是FF序列号、阈值、产线信息全丢了最后只能重新返工。问题出在我自己写的“土方法”只在应用层做了简单的地址分配根本没考虑掉电时序和磨损均衡。后来我意识到与其每次都重复造轮子不如把成熟的开源组件组合起来。SFUD负责把W25Q64的底层驱动抽象好FAL负责把Flash切成逻辑分区FlashDB负责在分区之上提供带掉电保护的键值存储各司其职。1.2 三个库各自解决什么问题我把三个库的分工整理成一张表方便理解组件解决的问题类比SFUD屏蔽不同SPI Flash的差异自动识别JEDEC ID提供统一的读、写、擦除接口像设备驱动让上层不用关心Flash具体型号FAL把Flash划分为多个逻辑分区避免不同功能互相覆盖像给硬盘分区不同分区放不同数据FlashDB在分区上实现键值/时序存储带掉电保护、磨损均衡、CRC校验像轻量级数据库应用层只管读写KV这套分工最大的好处是每一层都能独立测试。我可以在不跑FlashDB的情况下先用SFUD验证W25Q64硬件链路是否正常再用FAL验证分区配置是否合理最后才上FlashDB出问题时定位范围会小很多。如果直接一次性全上出了问题根本不知道是SPI时序不对、分区地址越界还是数据库配置错误。1.3 从写入到落盘的完整链路一次KV写入在数据流上大概是这样应用层调用FlashDB的fdb_kv_setFlashDB根据内部状态决定写到哪个扇区、哪个偏移然后调用FAL的分区写接口FAL把分区内的相对偏移换算成Flash设备的绝对地址再调用SFUD的写接口SFUD通过SPI控制器把数据真正写入W25Q64。从代码上看是多层嵌套但每一层只做自己的事情职责非常清晰。我给这套链路打过一个比方SFUD是底层送货的快递员FAL是物流分拣中心FlashDB是仓库管理系统。你要存一个包裹不需要亲自开车到货架边只需要告诉仓库管理员“我要存serial这个箱子”后面的事情由系统自动完成。这个抽象思路比自己在应用层直接握着地址指针到处飞要省心得多。2. SFUD移植第一步SPI和W25Q64的基础读写2.1 万耦开发板上的SPI接线与初始化在万耦开发板上W25Q64一般通过SPI1或者SPI2外挂具体引脚要翻原理图确认。常见的四根信号线是SCK、MOSI、MISO、CS其中CS用普通GPIO控制比较灵活。特别要注意W25Q64的WP写保护和HOLD保持引脚这两个脚不能悬空最好直接拉高否则会出现写不进数据或者通信卡住的诡异问题。我第一次调试时WP空了结果擦除命令发下去没反应浪费了一下午。SPI参数方面W25Q64支持Mode0和Mode3我习惯选Mode0也就是CPOL0、CPHA0和大多数MCU的SPI默认配置一致。速率方面虽然W25Q64最高支持104MHz但我在验证硬件链路时先降到1MHz排除干扰后再逐步提升。下面是一段框架代码具体接口函数名跟着SDK走void w25q64_spi_init(void) { // 1. GPIO复用配置把SCK/MOSI/MISO复用为SPI功能 gpio_set_mux(SPI_SCK_PIN, GPIO_MUX_SPI_SCK); gpio_set_mux(SPI_MOSI_PIN, GPIO_MUX_SPI_MOSI); gpio_set_mux(SPI_MISO_PIN, GPIO_MUX_SPI_MISO); // 2. CS配置为普通GPIO输出空闲拉高 gpio_set_mode(SPI_CS_PIN, GPIO_MODE_OUTPUT); gpio_write(SPI_CS_PIN, 1); // 3. SPI参数Mode01MHz起步MSB先行 spi_config_t cfg {0}; cfg.mode SPI_MODE_0; cfg.freq 1000000; cfg.bit_order SPI_MSB_FIRST; spi_init(SPI1, cfg); }2.2 SFUD适配填好sfud_port.c就算完成一大半SFUD的移植工作主要在两个回调函数里。一个是sfud_spi_port_init负责初始化SPI和CS引脚另一个是sfud_spi_write_read负责一次SPI双向传输。SFUD在初始化时会自动发送读ID命令0x9F读取W25Q64的JEDEC ID并匹配内部型号表。W25Q64的JEDEC ID是0xEF4017如果串口打印出来是这个值说明SFUD已经认出了Flash。sfud_err sfud_spi_port_init(sfud_flash *flash) { w25q64_spi_init(); return SFUD_SUCCESS; } sfud_err sfud_spi_write_read(const sfud_spi *spi, const uint8_t *write_buf, uint8_t *read_buf, size_t size) { gpio_write(SPI_CS_PIN, 0); spi_transfer(SPI1, write_buf, read_buf, size); gpio_write(SPI_CS_PIN, 1); return SFUD_SUCCESS; }适配完成后主函数里调用sfud_init()然后通过sfud_get_device_table()拿到Flash设备指针。我习惯先把flash-id打印出来确认识别结果再做读写测试。2.3 第一个读写验证基础读写测试我喜欢用这种流程先读原始数据再擦除一个扇区写入固定模式最后回读比对。这样能一次性验证读、擦、写三个核心动作。sfud_flash *flash sfud_get_device_table()[0]; uint8_t buf[256]; // 1. 读地址0x000000前256字节 sfud_read(flash, 0x000000, sizeof(buf), buf); // 2. 擦除0x000000所在的4KB扇区 sfud_erase(flash, 0x000000, 4096); // 3. 写入0x5A模式 memset(buf, 0x5A, sizeof(buf)); sfud_write(flash, 0x000000, sizeof(buf), buf); // 4. 回读并比对 memset(buf, 0, sizeof(buf)); sfud_read(flash, 0x000000, sizeof(buf), buf);这里要注意sfud_erase的地址和长度必须按扇区对齐。W25Q64一个扇区是4KB所以擦除起始地址必须是4096的整数倍长度也尽量整扇区。如果参数区不是4KB对齐后面做FAL分区时会埋下越界隐患。3. FAL分区表把8MB Flash管理成三个仓库3.1 FAL是什么Flash之上的“分区表”FAL的全称是Flash Abstraction Layer它做的事情很简单在Flash设备之上建立一张分区表每个分区有独立的名字、起始地址和长度。为什么需要这一层因为实际项目里Flash空间要被多处使用固件备份、参数存储、日志记录各占一块如果直接在代码里写死地址后续调整布局会非常痛苦。有了FAL所有上层代码只认分区名不关心物理地址。在万耦开发板上我把W25Q64的8MB空间分成两个分区前4MB给“param”参数区后4MB给“log”日志区。当然你也可以把前面的Bootloader、App固件都划进去分区表只是个数组想加几行加几行。3.2 注册W25Q64到FALFAL要操作W25Q64需要先实现一组回调包含init、read、write、erase四个函数。大部分工作可以直接转发给SFUD完成。注册代码如下static int w25q64_init(void) { return 0; } static int w25q64_read(long offset, uint8_t *buf, size_t size) { return sfud_read(w25q64_flash_dev, offset, size, buf); } static int w25q64_write(long offset, const uint8_t *buf, size_t size) { return sfud_write(w25q64_flash_dev, offset, size, buf); } static int w25q64_erase(long offset, size_t size) { return sfud_erase(w25q64_flash_dev, offset, size); } const struct fal_flash_dev w25q64_flash_dev { .name w25q64, .addr 0, .len 8 * 1024 * 1024, .blk_size 4096, .ops w25q64_flash_ops, }; void w25q64_fal_register(void) { fal_flash_device_register(w25q64_flash_dev); }blk_size填的是物理块大小对W25Q64来说就是4KB。FAL擦除、地址对齐判断都会拿这个值做基准填错会直接导致上层操作越界。3.3 分区表定义分区表在fal_cfg.c里定义每一行包含魔法字、分区名、设备名、起始地址、长度和保留字段。我用的是两个4MB分区的方案#define FAL_PART_TABLE \ { \ {FAL_PART_MAGIC_WORD, param, w25q64, 0, 4 * 1024 * 1024, 0}, \ {FAL_PART_MAGIC_WORD, log, w25q64, 4 * 1024 * 1024, 4 * 1024 * 1024, 0}, \ }配置分区表时最容易被忽略的是“起始地址和长度必须扇区对齐”。W25Q64擦除扇区是4KB所以地址如果写成了比如4097擦除时就会跨扇区逻辑很混乱。我在测试时故意把param的起始地址改成了0x1000却发现擦除操作把后一个分区的头部也带走了就是因为对齐没处理好。3.4 FAL读写API演示注册并定义分区表后上层代码就能通过分区名直接操作了。下面这段是典型用法const struct fal_partition *param_part fal_partition_find(param); uint8_t data[64] {0}; fal_partition_write(param_part, 0, data, sizeof(data)); fal_partition_read(param_part, 0, data_buf, sizeof(data_buf)); fal_partition_erase(param_part, 0, 4096);注意fal_partition_write的偏移是“分区内偏移”不是Flash物理地址。FAL内部会自动加上分区的起始地址。这时候再回头看直接操作W25Q64时的地址换算就会发现FAL把这块脏活彻底包掉了。4. FlashDB键值存储代码与掉电实测4.1 初始化FlashDBFlashDB的核心能力是KVDB特别适合存设备参数。它内部维护了一套掉电安全机制写入时先写临时区、做CRC校验确认无误后再更新正式记录。初始化时最关键的两个参数是partition和sector_size前者对应FAL里的分区名后者必须和Flash物理扇区一致W25Q64就是4096。#include flashdb.h struct fdb_kvdb kv_db; struct fdb_kvdb_cfg kv_cfg { .name param_db, .partition param, .sector_size 4096, .blob_value true, }; fdb_err_t err fdb_kvdb_init(kv_db, kv_cfg); if (err ! FDB_NO_ERROR) { // 初始化失败检查分区名和分区大小 }这里有个隐藏要求分区大小至少是sector_size的三倍。FlashDB要用多个扇区做滚动写入和备份如果只给一个4KB扇区初始化直接失败。我一开始给param只分了8KBFlashDB跑起来后很快写满换了16KB才稳定。4.2 KV写入、读取、删除初始化完成后KV操作非常简单和平时用字典差不多。以设备序列号为例uint32_t serial 0x12345678; fdb_kv_set(kv_db, serial, serial, sizeof(serial)); uint32_t read_serial 0; fdb_kv_get(kv_db, serial, read_serial, sizeof(read_serial)); fdb_kv_del(kv_db, serial);如果存字符串直接传strlen长度就行读取时传缓冲区长度FlashDB会返回实际长度。这里我的建议是单条KV的value长度最好不要超过一个扇区的一半也就是2KB左右。因为FlashDB的掉电保护是以扇区为粒度的跨扇区写入会增加掉电时的不确定因素能避免就避免。4.3 掉电保存测试方法真正检验方案是否靠谱还得靠掉电测试。我的做法是写一个测试线程循环执行“写入KV → 立即硬断电 → 重新上电 → 读取KV → 比对”的流程。测试结果如下测试场景操作结果正常写读写入500个KV后全部读取比对通过写入后立即断电写入serial后马上断电重新上电读取通过擦写过程中断电在FlashDB执行擦除时断电重启后检查历史数据通过自动回退到上一版本批量连续掉电连续重复掉电100次通过无数据丢失FlashDB之所以能扛住这些场景核心是双扇区备份机制。写入时不会直接覆盖旧数据而是先写到一个空闲扇区校验通过后再更新管理信息。如果在写入中途掉电重启后系统会发现记录不完整自动丢弃半截数据保留上一次的完整记录。这个设计思路和我之前自己用“读改写”那种土办法完全是两个维度。5. 移植路上踩过的五个坑与最终稳定状态5.1 SFUD读出全0xFF先查SPI模式还是CS极性现象是sfud_init成功但读取任意地址都返回全FF。排查时我发现JEDEC ID也读成了0xFFFFFF说明SPI链路上压根没有数据回来。我依次查了接线、供电、GPIO复用最后定位到是CS引脚被板载另一路外设默认拉低导致W25Q64一直处于选中状态SPI总线被干扰。解决方式是调整初始化顺序把CS引脚在SPI初始化之前先拉高并检查板级复用表确认没有其他外设抢占这个引脚。5.2 FAL擦除边界越界有一次用fal_partition_erase擦除param分区最后4KB时把log分区的头几个字节也擦掉了。查下来发现分区表里param的起始地址写成了0x1FFC不是4096的整数倍FAL在做地址换算时按块取整导致实际擦除范围越界。这提醒我分区表的每个地址、长度都要用宏或计算表达式来保证对齐不要手写魔数。5.3 FlashDB初始化失败fdb_kvdb_init返回错误码这类问题其实最好定位。先确认FAL里是否真的存在param分区用fal_partition_find打印一下再确认sector_size是否和Flash物理扇区一致最后看分区大小是否足够至少要满足三个扇区的容量。我一开始在配置里把sector_size填成512W25Q64实际扇区是4096初始化当然过不了。5.4 掉电测试偶发丢KV连续掉电测试到几十次后偶尔会出现一条KV读不回来的情况。一开始怀疑FlashDB有bug后来查看日志发现丢的都是同一个超大value。排查原因是我把一个结构体当KV value写进去长度超过了4KB扇区的一半FlashDB内部跨扇区操作在断电瞬间出现了不确定性。把value拆成多个小KV后再跑200次掉电测试一次都没丢。5.5 性能和稳定性数据最后分享一下我在万耦开发板上跑出的实测数据SPI时钟20MHzFlashDB开启blob模式操作实测耗时SFUD读取1KB约0.3msSFUD写入1KB约1.2ms擦除一个4KB扇区约45msFlashDB写入一条16字节KV约1.5msFlashDB读取一条16字节KV约0.4msRAM占用方面FlashDB本体大约1KB左右再加上一个4KB的扇区缓冲在MCU上完全能接受。从稳定性看连续掉电200次、写入并读回1000条KV均无丢失。这个方案的边界清晰性能也够用。最后再分享一个小技巧如果准备在万耦开发板上做W25Q64的掉电存储建议把SFUD、FAL、FlashDB三层分开调试先在底层把JEDEC ID读出来再测单个扇区擦写然后配FAL分区最后上FlashDB。每一步都验证通过再进入下一步出问题时排错会快很多。我后来把设备参数、运行日志、固件备份都放进了这套分区管理里整个项目的存储安全感提升了一大截。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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