做嵌入式开发的人迟早会撞上“数据掉电丢失”这堵墙。前几年我调试一台批量出货的采集设备客户模拟意外断电跑了不到一百次参数就乱成一锅粥——问题就出在裸写内部Flash既没有均衡磨损也没有掉电保护。从那以后凡是涉及需要频繁修改的配置参数、运行日志我都不敢再直接拿Flash地址硬怼了。这套方案就是目前嵌入式圈子里口碑比较稳的组合SFUD管底层SPI Flash驱动FAL做分区管理和Flash抽象flashDB提供掉电安全的KV数据库和时序数据库接口。三者搭配在STM32上做存储系统基本可以覆盖从BootLoader到应用程序、从内部Flash到外部SPI Flash的绝大多数场景。这篇文章主要面向已经在用STM32但还没系统性接触存储分层的开发者也适合准备把参数存储、日志记录、OTA升级分区整合到一块的人。我会把移植过程拆开讲顺带把调试时容易踩的坑都列出来。1. 为什么是这三件套存储方案的选型逻辑1.1 三者在系统中的分工很多初学STM32的人觉得存储不就是写地址、擦扇区、写数据三步如果只是存一个固定版本号确实没问题。但一旦面对“配置参数需要频繁更新”“固件升级需要保留历史”“日志要循环写入”这类需求时裸读写就会让人痛不欲生每次改一个字节都要考虑擦除粒度、地址对齐、掉电时数据的一致性。这三件套是典型的分层架构思路各管一摊事SFUDSerialize Flash Universal Driver是底层的SPI Flash驱动层专治W25Q系列、GD25系列这类外部NOR Flash。它通过标准SPI或QSPI接口把Flash变成一块可以按扇区擦除、按页写入的裸设备。它最大的价值是能自动解析JEDEC ID和SFDP参数换一颗Flash芯片不用改应用代码。FALFlash Abstraction Layer是分区管理层解决的是“Flash资源怎么分”的问题。它把内部Flash和外部SPI Flash统一成几个逻辑分区比如boot、app、kv、download每个分区有独立的起始地址和大小。FAL的存在让上层应用不用关心某块数据到底放在内部还是外部Flash也不用担心地址写穿。flashDB把FAL分区包装成数据库接口。最常用的是KVDB键值存储直接以键值对方式读写内部自带磨损均衡、掉电保护、GC垃圾回收。另一个是TSDB时序存储适合存传感器历史数据、日志点按时间序列写入效率很高。一个典型的调用关系是这样的应用代码只跟flashDB打交道flashDB通过FAL分区读写数据FAL再把请求分发给具体设备驱动——要么是SFUD管理的SPI Flash要么是STM32内部Flash操作函数。1.2 为什么不直接裸读写Flash裸读写最大的问题不是性能而是一致性。NOR Flash的物理特性决定了写入前必须擦除且擦除以扇区为单位。假设你有一个四字节的参数存在0x08010000处想更新它逻辑上应该读原扇区数据到缓冲、修改目标字节、整片擦除、然后把整个扇区写回去。这个过程中任何一次掉电都会导致这个扇区内的所有参数集体损坏。flashDB的掉电保护是怎么做的它借鉴了日志型文件系统的思路写入时先在一个管理扇区打标记再更新数据扇区最后提交事务。如果中途断电重启后数据库能根据标记判断哪一步没完成回滚到上一个合法状态。这在实测里非常管用我在项目测试中断电次数累计上千次数据库依然能自愈。1.3 为什么不直接上文件系统也许有人会问直接用LittleFS或FatFS不是更常见吗文件系统适合存文件名、目录结构、较大块的数据但代价是挂载过程复杂、资源占用偏高、一旦目录块损坏恢复困难。而KVDB这种形态恰好和“配置参数背诵”高度吻合每一个设备信息、校准系数、用户设置就是一个键值对写入频次高、单条数据小根本不需要目录和文件名那一套重机制。用KVDB还有一个隐藏优势它对不同Flash的写粒度自适应做得很好。有的Flash支持按字节写有的只能按16位字写flashDB通过配置项FDB_WRITE_GRAN就能适配。这一点裸写的话需要自己操心做起来非常容易出错。2. 环境准备与整体框架2.1 软硬件基础与工具链本次移植以STM32F103ZET6为例开发环境使用Keil MDK 5.30及以上版本。硬件上有两种情况一是只有内部Flash那么FAL直接对接STM32标准库或HAL库的Flash操作二是存在外部SPI Flash片选、时钟、MISO/MOSI要接到具体GPIO我在文中以W25Q64通过SPI1接入为例。编译三件套时建议准备以下源码SFUD从GitHub拉取armink/SFUD仓库里面有sfud源码目录。FAL从GitHub拉取armink/FAL仓库inc目录下是头文件src目录下是核心实现。flashDB从GitHub拉取armink/FlashDB仓库src目录有fdb_kvdb.c、fdb_tsdb.c、fdb_utils.c等。这三个库由同一个作者主导互相之间兼容性照顾得比较周到接口风格一致文档里也会互相引用组合起来比混搭其他独⽴组件省心得多。2.2 源码文件如何组织进工程这三个库对编译环境没有强制要求纯C实现基本上就是把以下文件拉进Keil工程SFUD需要sfud.c、sfud_sfdp.c、sfud_flash.c其中sfud_flash.c里维护Flash参数表sfud_sfdp.c是解析SFDP的模块。FAL需要fal.c、fal_flash.c、fal_partition.c另外需要一个fal_flash_stm32f1_port.c内部Flash移植文件或者fal_flash_sfud_port.c对接SFUD的移植文件这个文件一般需要自己写。flashDB需要fdb_kvdb.c、fdb_tsdb.c、fdb_utils.c另外有一个fdb_cfg.h头文件用来配置裁剪。标准的Keil工程需要把FAL_SRC、SFUD_SRC、FlashDB_SRC这些路径加进Include Paths不然头文件会找不到。我习惯新建一个storage目录把三个库的源码统一放进去main函数外单独封装一层storage_init()接口后续业务代码只调它看起来会干净很多。2.3 Flash布局与分区规划动手写代码之前先把Flash分区表在纸上画出来。这个步骤非常重要能避免后续烧写BootLoader把应用覆盖、或者数据库和固件互相踩踏的麻烦。以STM32F103ZET6为例内部Flash是512KB外部W25Q64是8MB。内部Flash可以规划为boot区64KB用于BootLoader。app区剩余448KB用于应用固件。外部Flash可以规划为download区2MBOTA升级临时存放固件。kv区1MBflashDB的KV数据库专用。tsdb区2MB时序数据区。分区不一定要一次规划完但KV区建议至少给1MB因为flashDB做GC和磨损均衡需要空间周转空间太小会让初始化失败概率上升。外部Flash比较大给KV区多点浪费不了多少换来的是寿命明显延长。3. 第一层SFUD将SPI Flash变成标准块设备3.1 先让MCU和Flash对上话移植SFUD的第一步是先把SPI外设驱动跑通。这里不赘述SPI初始化代码但有一点强调一下SFUD只是封装Flash协议它不帮初始化SPI内部的GPIO和时钟这些需要你提前配置好。我常用的W25Q64接线是这样SPI1_SCK - PA5SPI1_MISO - PA6SPI1_MOSI - PA7SPI1_CS - PA4软件控制CSSPI模式必须设置为模式0和模式3都能工作W25Q64手册要求SPI Mode 0或Mode 3均支持但实际接到某些MCU SPI上时更建议用Mode 0也就是CPOL0、CPHA0稳定性最好。SPI频率先别调太高W25Q64标称支持104MHz但那只是极限规格PCB走线不好、杜邦线连接时高频容易出错。我调试时习惯先用2.25MHz起步稳定运行后再逐步提高分频比最终在项目里定在18MHz数据传输和稳定性处于平衡点。配置完成后写一个简单的spi_flash_read_id测试函数能读回0xEF 0x40 0x17对应W25Q64的JEDEC ID说明SPI链路已经通了再进行SFUD移植也不迟。3.2 SFUD初始化与验证SFUD的初始化非常简单核心是调用sfud_init()。它会遍历sfud_cfg.h里配置的Flash设备表通过sfud_probe识别挂在SPI总线上的Flash型号然后从芯片ID匹配内部参数表。我用的是默认配置sfud_cfg.h里通常会这样写#define SFUD_USING_SFDP #define SFUD_USING_FLASH_INFO_TABLE #define SFUD_SPI_MAX_HZ 50000000如果你不希望依赖SFDP解析可以只保留SFUD_USING_FLASH_INFO_TABLE这样SFUD会根据JEDEC ID直接在内部查找W25Q64的参数速度更快。两个功能同时打开也没有问题SFUD会优先尝试SFDP解析解析不到再回退到内部参数表。初始化之后要验证底层读写是否可靠。测试代码我一般这么写sfud_flash *flash sfud_get_device_table()[0]; uint8_t buffer[4096]; uint8_t read_buf[4096]; memset(buffer, 0xA5, sizeof(buffer)); sfud_err result sfud_erase(flash, 0x000000, 4096, NULL); result sfud_write(flash, 0x000000, sizeof(buffer), buffer, NULL); result sfud_read(flash, 0x000000, sizeof(read_buf), read_buf, NULL); if (memcmp(buffer, read_buf, sizeof(buffer)) 0) { // 读写一致 }这里有一个关键点sfud_erase的擦除范围必须是整扇区对齐SFUD会按照Flash实际的扇区大小自动对齐擦除边界。但如果你传入的地址不是扇区边界返回值会报错或者行为未定义所以测试时从0地址开始最稳妥。sfud_write要求写入长度不超过页大小常见是256字节且不能跨页SRAM临时缓冲区要留出足够空间我用uint8_t buffer[4096]是因为后面会测试多页连续写入。3.3 底层读写测试与速率调整SFUD就绪后不要急着往上层冲建议先做一个完整的读写压力测试。把上面那一段代码包在一个循环里从0地址开始按4KB粒度擦写整个芯片或某个区域写入模式可以交替填充0xA5、0x5A、随机数然后立刻读回比对。我实测过W25Q64在18MHz SPI频率下连续擦写一万次不出错基本可以排除硬件时序问题。如果出现数据错位、读回全是0xFF、偶尔第一个字节错误优先级检查以下三点SPI模式不对重新确认CPOL/CPHA。软件CS引脚在SPI传输过程中被其他中断抢占了导致片选时序断裂。这种情况在RTOS环境或频繁中断环境较常见需要在CS拉低和拉高之间加临界区保护或者改用硬件SPI原生NSS。SPI分频太高杜邦线连接时不要超过9MHz。实测下来速度不是SFUD阶段最需要关心的指标。先把稳定性打牢后面FAL和flashDB叠加时排错才会轻松。4. 第二层FAL统一管理内部Flash和外部SPI Flash4.1 FAL的分区表怎么配SFUD已经让外部Flash变成了可读可写可擦除的设备但上层应用如果直接使用SFUD还是得记“外部Flash几号、偏移多少、长度多少”这非常难受。FAL的介入把这一层封装了。FAL核心概念有两个Flash设备和分区。Flash设备对应一块物理存储比如norflash对应W25Q64分区是逻辑概念比如app、download、kv。分区表定义在fal_cfg.h里核心是一个宏表格#define FAL_FLASH_DEV_TABLE \ { \ {FAL_FLASH_DEV_TABLE_MAGIC, norflash, NULL, 0, 8 * 1024 * 1024, 0}, \ } #define FAL_PART_TABLE \ { \ {FAL_PART_MAGIC_WROD, download, norflash, 0, 2 * 1024 * 1024, 0}, \ {FAL_PART_MAGIC_WROD, kv, norflash, 2 * 1024 * 1024, 1024 * 1024, 0}, \ {FAL_PART_MAGIC_WROD, tsdb, norflash, 3 * 1024 * 1024, 2 * 1024 * 1024, 0}, \ }注意一个细节FAL_FLASH_DEV_TABLE中norflash的底层操作函数指针我留成NULL是因为FAL在注册设备时需要一个const struct fal_flash_dev结构体里面包含read、write、erase函数指针。SFUD对接FAL的移植文件里这几个函数会调用sfud_read、sfud_write、sfud_erase来实现。如果不用SFUD内部Flash也完全可以注册成另一个设备只需要给内部Flash单独实现一套read/write/erase函数。这就实现了“外部SPI Flash 内部Flash”的统一分区管理。4.2 基于STM32的Flash操作实现FAL官方给每个平台都提供了移植示例STM32F1的移植文件fal_flash_stm32f1_port.c里需要实现几个函数。内部Flash的读操作很简单STM32内部Flash在系统总线直接映射了地址直接按地址读取即可。写操作稍微绕一点STM32内部Flash写入之前必须擦除F1系列的页大小是1KB。我常用HAL库实现内部Flash的写函数大致是static int onchip_flash_write(long offset, const uint8_t *buf, size_t size) { uint32_t addr onchip_flash.addr offset; uint32_t end_addr addr size; HAL_FLASH_Unlock(); __disable_irq(); // 内部Flash写入期间不允许中断 while (addr end_addr) { uint32_t data 0; memcpy(data, buf (addr - (onchip_flash.addr offset)), 4); if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr, data) ! HAL_OK) { __enable_irq(); HAL_FLASH_Lock(); return -1; } addr 4; } __enable_irq(); HAL_FLASH_Lock(); return size; }注意HAL_FLASH_Program一次写4字节因此写操作的地址必须4字节对齐长度也最好是4的倍数。如果你的参数长度不满足对齐要求FAL会通过内部缓冲处理好但自己在应用层调用时最好不要违反这个约束。内部Flash的擦除函数需要计算好扇区库。F1的页是1KB按偏移量取整后循环擦除即可擦除代码里也要放在HAL_FLASH_Unlock()和中断关闭的保护范围内。4.3 在链接层面避开互相踩踏分区表配好了Flash操作函数写好了还欠一道工序确保应用程序的链接地址和分区表一致。比如分区表规划内部Flash前64KB是boot区但应用链接脚本里ROM_START如果还是0x08000000那启动后应用固件就会和BootLoader重叠运行必然混乱。所以我习惯在keil的Target选项卡中修改IRAM和IROM地址如果BootLoader占前面64KB那么应用的IROM1起始地址改成0x08010000Size改成0x00070000。这一步做完后再在系统初始化时加一个编译期断言保证链接地址和FAL分区表一致比如检查extern int Image$$ER_IROM1$$Base的地址与分区表app区起始地址是否相等。这种防御性代码能避免多人协作时有人改了链接脚本但忘了改分区表。5. 第三层flashDB提供掉电安全的KV数据库5.1 KVDB初始化流程当SFUD和FAL都稳定工作时最后一片拼图就是flashDB。flashDB有两种模式KVDB和TSDB。项目里最常用的是KVDB下面的移植过程以KVDB为例。源码里需要引入的头文件是flashdb.h配置头文件fdb_cfg.h里至少要定义#define FDB_USING_KVDB #define FDB_USING_FAL_MODE #define FDB_SECTOR_SIZE 4096 #define FDB_BLOCK_SIZE 4096 #define FDB_WRITE_GRAN 1几个关键配置的含义先说清楚FDB_SECTOR_SIZEFlash的最小擦除单元。W25Q64的扇区就是4KB所以填4096。如果用的Flash页大小是1KB可以填1024但SFUD底层擦除会按实际扇区对齐自己核对一下。FDB_BLOCK_SIZE同一个Flash上block大小一般和FDB_SECTOR_SIZE相等即可。FDB_WRITE_GRAN写粒度。NOR Flash一般按字节写填1。初始化KVDB的代码模板#include flashdb.h fdb_kvdb_t kvdb; static const struct fdb_default_kv default_kv[] { {device_name, stm32f103, 0}, {baudrate, 115200, 0}, }; struct fdb_default_kv default_kvs { default_kv, sizeof(default_kv) / sizeof(default_kv[0]), }; int storage_init(void) { fdb_err_t result fdb_kvdb_init(kvdb, env, norflash, kv, default_kvs, NULL); if (result ! FDB_NO_ERR) { return -1; } return 0; }fdb_kvdb_init的参数依次是数据库对象、数据库名称、挂载的FAL设备名、FAL分区名、默认键值表、额外的配置项。默认键值表的作用是数据库首次初始化为空时自动写入这些键值非常方便工厂产线首次开机下发默认参数。如果不需要默认值直接传NULL。5.2 读写API与默认值机制KVDB的使用方式远比裸Flash优雅// 写入 fdb_err_t result fdb_kv_set(kvdb, baudrate, 9600); if (result FDB_NO_ERR) { // 写入成功 } // 读取 char value[32] {0}; size_t value_len sizeof(value); result fdb_kv_get(kvdb, baudrate, value, value_len); if (result FDB_NO_ERR value_len 0) { // value里就是字符串9600 } // 删除 result fdb_kv_del(kvdb, baudrate);值得注意的是fdb_kv_set内部会在原扇区写入新值然后把旧值标记为删除而不是马上擦除。垃圾回收器会在后台根据阈值触发触发时再把无效数据清理掉。这意味着删除键是安全的只要新数据没提交旧数据仍然可用于掉电恢复。默认值机制还有一个妙用当KVDB检测到键不存在或者数据库被格式化后它会自动用默认键值表初始化。这在产品量产时特别好用——同一份固件烧进不同型号设备只要通过外部手段比如串口命令写一次设备型号数据库就会把这个值持久化之后即使重启也不会恢复到默认值。5.3 分区大小与性能参数选择KVDB初始化对分区大小是有底线的。FAL分区的kv区如果太小比如只有一个4KB扇区数据库初始化会直接失败。原因就是它至少要有一个扇区作为管理区另外一个扇区作为活跃数据区等后续GC时还需要临时周转扇区。我建议的最小KV分区是64KB即16个扇区。1MB空间已经能让KVDB运行得非常顺畅。存储传感器历史数据可以考虑用TSDB模式它写入是纯追加模式性能很高但占用的分区规划逻辑不同两者不要混在同一个分区。fdb_cfg.h里还有一个可选配置项FDB_KV_AUTO_GC默认开启。自动GC让数据库在写入一定量的无效键后自动回收空间非常适合长期运行的设备。如果你对写入时延有严格要求可以把自动GC关掉在系统空闲时手动调用fdb_kv_gc(kvdb)执行这样写入峰值时延更可控。6. 常见问题与避坑技巧实录6.1 高频问题排查表我在多个项目里反复踩过的坑整理成了排查表任何一项都可能会让移植从“看起来正常”变成“随机死机”问题现象常见原因解决方案初始化返回错误数据库打不开分区太小、FDB_SECTOR_SIZE与实际不符扩大KV分区核查Flash扇区大小写入或读取数据偶尔错乱SPI频率过高或SPI模式不对降低SPI分频确认CPOL/CPHA掉电后部分数据丢失写操作被中断打断或未能配置FDB_WRITE_GRAN检查写粒度配置不要在中断里执行KV写入内部Flash读保护/写保护未解锁或Option Bytes配置调用HAL_FLASH_Unlock检查读保护等级KVDB写入触发看门狗复位擦除时间过长导致喂狗超时在擦除循环中喂狗或者合理调整看门狗超时值程序全速跑一段时间后Flash无法写入磨损均衡使空间占满GC未能正常回收检查KVDB分区是否过小开启自动GCSPI传输时偶尔卡死SPI NSS/CS信号被其他中断抢占在CS切换期间关闭中断或使用硬件NSSKVDB初始化的错误码很容易被忽略。fdb_kvdb_init返回FDB_INIT_FAILED时很多时候不是代码逻辑问题而是分区大小不够。你可以在分区表里临时把kv地区划到8MB外部Flash足够大如果是分区太小导致的初始化失败扩大后立刻能过。6.2 烧写、加载与调试阶段注意使用STM32内部Flash做FAL设备时有一个让很多人崩溃的问题Keil通过ST-Link下载程序时如果芯片设置了读保护RDP或写保护WRPFlash操作会直接失败。保险的调试做法是在烧写前把Option Bytes里的保护等级设为Level 0测试完再按产品要求设置保护等级。另外flashDB有些平台的支持文件依赖RT-Thread的dbg日志宏。如果直接裸机使用需要自己在fdb_cfg.h里定义FDB_PRINT宏和FDB_DEBUG宏否则编译会报一堆未定义标识符。最简单的方式是参考SFUD的sfud_cfg.h里的输出宏定义统一改为调用串口打印函数#define FDB_PRINT(...) printf(__VA_ARGS__) #define FDB_DEBUG(...)FDB_DEBUG平时留空出问题时再打开详细日志。flashDB打印的日志非常清晰会直接告诉你哪个扇区被标记为坏块、哪个键被GC回收了调试效率高一个量级。6.3 关于性能与稳定性的几点建议SFUD和FAL层在传输大数据块时会有内存拷贝的开销比如每次写4KB需要先填充一个SRAM缓冲。对于资源紧张的STM32F103RAM只有64KB建议不要一次请求写超大块而是拆分到1KB以下。实测下来这样既不会爆RAM也能避免SPI DMA时缓冲区地址未对齐的问题。还有一点是动态内存的问题。flashDB默认支持在静态内存或动态内存下运行如果你的RTOS堆比较小建议使用静态分配方式在初始化时传入预分配的buffer。最后一个建议也是我吃过亏之后才总结出来的不要把关键参数和日志数据都放进KVDB。KVDB是给小文件频繁修改设计的一次写入几十字节很好用但如果你要连续记录几千条日志KVDB的GC开销会比较大寿命也不划算。这种场景改用TSDB或者直接走FAL分区写一个循环日志缓冲区更合适。7. 写在最后的经验从裸Flash一路走到三件套最大感受就是存储这件事越早分层越省心。SFUD把硬件差异吞掉FAL把空间划分清晰flashDB把并发和掉电问题挡在门外你只需要关心业务数据本身。刚开始移植时不要贪快按“底层→中层→上层”的顺序逐层验证每一步都有明确测试通过标准再进下一步这样即使出问题也能快速定位。最后提醒一句正式量产前一定要做掉电老化测试真实环境里一次意外断电带来的破坏往往比十万次正常读写更能检验方案的成色。
阅读完成 · 觉得有帮助?