先聊点实在的:STM32项目里做数据存储,大多数人一开始都会走弯路。要么直接怼一片裸的SPI Flash,应用层自己拼读写地址、自己管擦除、自己记偏移量,结果代码越写越绕;要么选个E2PROM从头到尾硬扛,容量不够改方案,掉电写坏数据查半天。我这次在STM32F407上,把FAL、SFUD、flashDB这三个组件一次接齐,存储层从硬件驱动到应用接口全部捋顺,整个过程踩了不少坑,也总结出一套可以直接照抄的移植流程。这篇文章把每个组件的角色、移植步骤、关键配置和实际踩坑经验都摊开讲,适合正在做参数存储、日志记录、OTA升级分区管理的朋友参考。
1. 先搞清楚三件套各自的定位
1.1 在没有三件套之前,我踩过的存储坑
早期做产品,我习惯直接用SPI Flash硬编码读写。比如W25Q64,芯片8MB,我先把前1MB划分给参数区,后面全放日志。听起来简单,但真做起来很痛苦:应用层要自己去算扇区偏移,要记录“上一条日志写到哪了”,一旦程序版本迭代、参数结构体变了,新旧数据兼容全靠手动迁移。
更头疼的是可靠性问题。Flash写入之前必须先擦除,而擦除以扇区为单位,W25Q64一个扇区是4KB。我早期为了改一个4字节的参数,直接把整个扇区擦掉再写回去,中途一旦断电,整块参数区直接报废。后来我试过用备份区和标志位做原子操作,代码写了几百行,逻辑还是漏洞百出。
这个阶段最大的问题不是能力不够,而是架构不对。存储这件事从上到下本来应该分层:最底层管硬件差异,中间层管分区划分,最上层管应用读写和可靠性。自己直接一把梭,等于把这三层全部耦合在一个模块里,改动一个芯片、增加一个功能,都要重写一大堆代码。
1.2 三件套的分工与协作逻辑
SFUD、FAL、flashDB这套组合,正好对应我上面说的三个层次。
SFUD(Serial Flash Universal Driver)负责最底层,把不同厂家的SPI Flash封装成统一接口。它内部维护了一个芯片型号表,W25Q64、GD25Q64这类常见芯片都能自动识别,识别之后读写擦除全走一套API,换芯片时不用改上层代码。
FAL(Flash Abstraction Layer)是做分区管理的,类似PC上的磁盘分区。它把一片物理Flash划分成多个逻辑分区,比如App区、参数区、日志区,每个分区有名字、有起始地址、有长度,上层读写全部通过分区名操作,底层地址变化完全不感知。
flashDB是跑得最上层的一个轻量级嵌入式数据库,它不直接接触硬件,而是在FAL的分区之上提供两种能力:一种是KV(键值对)存储,适合存设备参数、校准数据;另一种是TSDB(时序数据)存储,适合存日志、采集数据。掉电安全、磨损均衡、数据索引这些事,它都帮你做好了。
三者协作链路非常清晰:SFUD让物理Flash可用,FAL把Flash切成多个逻辑空间,flashDB在逻辑空间里提供数据库能力。应用层只面对flashDB的接口,底层换芯片、换分区方案,应用层代码一行都不用动。
2. 移植前的软硬件准备
2.1 硬件选型与接口确认
这次移植用的是STM32F407VET6主控,Flash芯片是W25Q64(8MB容量,4KB扇区),走SPI1接口。W25Q系列在国产和进口方案里占有率高,SFUD原生支持,参考价值最大。
接线其实没太多悬念,SPI的四个核心引脚按常规配置:SCK挂PB3,MISO挂PB4,MOSI挂PB5,CS挂PB12。这里特别注意一下,CS引脚一定要用软件控制,不能用硬件NSS自动管理,因为W25Q系列要求片选信号在指令期间全程拉低,硬件NSS的模式不好控制时序。
供电方面给W25Q64配一个100nF去耦电容,靠近VCC引脚放置,这个对稳定性影响很大。我早期测试时芯片经常“不识盘”,最后排查发现就是电源纹波太大,电容加上去问题立刻消失。
除了Flash本身,还需要一个用于调试的串口。因为SFUD和FAL本身没有可视化输出,排查问题时全靠串口日志辅助。波特率用115200,一个发送引脚就够了。
2.2 软件工程基线
软件方面,我用的开发环境是Keil MDK 5.30,标准外设库,没用HAL,原因很直接:身边团队的老项目都是标准库,迁移成本低,而且标准库的SPI读写代码直来直去,更容易看清楚时序问题。
三个组件的版本分别是:SFUD 1.1.0、FAL 0.5.0、flashDB 1.0.0。建议从官方Gitee仓库拉最新release,不要随便找别人改过的版本,我见过太多“移植失败”其实是用了一份别人魔改的代码包,问题根本没法查。
工程里需要提前建好三个文件夹:sfud、fal、flashdb,分别放对应源码。SFUD源码里src目录下放核心代码,port目录下放移植文件比如sfud_port.c,这个文件后面要重点改。FAL源码里同样有src和port目录,其中fal_flash_sfud_port.c负责把SFUD接口接入FAL,也是关键文件。
编译时要注意,Keil的C99模式必须打开,这三个组件的源码全都用到了C99特性,不打开会有大量编译报错。另外宏定义要预先加好:FDB_USING_KVDB、FDB_USING_TSDB按需开启,FDB_WRITE_GRAN按Flash的写粒度设置为1,这些细节后面展开说。
3. SFUD移植:让SPI Flash“开口说话”
3.1 关键移植文件sfud_port.c的改写
SFUD的移植工作基本都集中在sfud_port.c这个文件。它的作用就是告诉SFUD:底层的SPI是怎么调用的,读、写、擦除是怎么实现的。打开这个文件,默认会看到一个sfud_spi_init函数和若干空接口,把这些空接口填上就行。
我用标准库完成SPI1的初始化,然后封装了三个底层函数:spi_flash_read、spi_flash_write、spi_flash_erase。
static sfud_err spi_flash_read(const sfud_flash *flash, uint32_t addr, size_t size, uint8_t *data) { // 拉低CS GPIO_ResetBits(GPIOB, GPIO_Pin_12); // 发送读命令 0x03 spi_send_byte(0x03); // 发送24位地址,高字节在前 spi_send_byte((addr >> 16) & 0xFF); spi_send_byte((addr >> 8) & 0xFF); spi_send_byte(addr & 0xFF); // 连续读取size个字节 for (size_t i = 0; i < size; i++) { data[i] = spi_receive_byte(); } // 拉高CS GPIO_SetBits(GPIOB, GPIO_Pin_12); return SFUD_SUCCESS; }写操作稍微复杂一点,因为Flash写入前必须判断目标地址状态,如果没擦除过,写进去的数据就是错的。正常的流程是先发写使能命令0x06,再发写命令0x02,然后是24位地址,最后才是要写的数据。
static sfud_err spi_flash_write(const sfud_flash *flash, uint32_t addr, size_t size, const uint8_t *data) { // 发送写使能 GPIO_ResetBits(GPIOB, GPIO_Pin_12); spi_send_byte(0x06); GPIO_SetBits(GPIOB, GPIO_Pin_12); // 发送写命令和地址 GPIO_ResetBits(GPIOB, GPIO_Pin_12); spi_send_byte(0x02); spi_send_byte((addr >> 16) & 0xFF); spi_send_byte((addr >> 8) & 0xFF); spi_send_byte(addr & 0xFF); for (size_t i = 0; i < size; i++) { spi_send_byte(data[i]); } GPIO_SetBits(GPIOB, GPIO_Pin_12); // 等待写入完成,轮询状态寄存器 wait_busy(); return SFUD_SUCCESS; }擦除操作更简单,但需要注意,W25Q64支持三种擦除粒度:整片擦除、64KB块擦除、4KB扇区擦除。SFUD底层调用擦除时,是按FAL传下来的偏移和长度来的,偏移和长度可能不是4KB对齐,所以最稳妥的办法是让SFUD自己计算需要擦除哪些扇区,然后逐个扇区调用0x20命令完成擦除。
3.2 初始化与芯片探测
SFUD的初始化函数在不同版本里不太一样,老版本用sfud_init,新版本用rt_sfud_init,我用的1.1.0版本是rt_sfud_init。这个函数内部会自动调用sfud_port.c里的初始化接口,遍历所有Flash设备并尝试读取芯片ID。
调用完成后,用sfud_get_device_table()拿到Flash设备表,第一个元素就是我们的W25Q64。为了验证移植是否成功,我会做一个自检:读出芯片ID并打印,W25Q64的ID是0xEF4017,如果打印出来的ID和这个对得上,说明底层通信已经通了。
const sfud_flash *flash = sfud_get_device_table()[0]; printf("Flash device: %s, size: %dKB\r\n", flash->name, flash->chip.capacity / 1024); printf("JEDEC ID: 0x%06X\r\n", flash->chip.id);这里有个非常容易踩的坑:如果你的代码只调用了初始化,但没打印ID,不要以为初始化成功就等于读写没问题。SPI模式、时钟极性、相位只要有一个不对,芯片ID可能会读到一个错误值。我建议无论如何都要先做ID自检,这是SFUD移植的第一道关卡。
提示:W25Q系列默认支持Mode 0(CPOL=0、CPHA=0)和Mode 3(CPOL=1、CPHA=1)。SFUD内部默认按Mode 0初始化,如果你的SPI配置用的是Mode 3,芯片也能正常工作,但为了和SFUD保持一致,建议全部用Mode 0。
4. FAL移植:给Flash规划出合理的分区
4.1 FAL抽象层到底做了什么
FAL的全称是Flash Abstraction Layer,它要解决的核心问题,是让应用层不再关心“数据存在哪个物理地址”,只关心“数据属于哪个分区”。
它内部维护了一张“Flash设备表”和一张“分区表”。Flash设备表描述物理Flash,比如名叫norflash的W25Q64,起始地址0,长度8MB;分区表则在上面划分逻辑空间,比如app分区占前4MB,param分区占中间1MB,log分区占后面2MB。
每次读写时,FAL先根据分区名在分区表里找到对应的物理设备、起始地址和偏移量,然后调用Flash设备表里注册的底层回调函数。这套机制的优势很明显:如果以后把W25Q64换成了W25Q128,物理容量变了,只需要改Flash设备表的长度和分区表,应用层代码一个字节都不用动。
4.2 移植的关键环节与配置
FAL的移植核心在fal_flash_sfud_port.c,这个文件是SFUD和FAL之间的桥梁。我需要实现一个fal_flash_dev结构体,并填上init、read、write、erase四个函数指针。
static int fal_flash_init(void) { return 0; } static int fal_flash_read(long offset, uint8_t *buf, size_t size) { const sfud_flash *flash = sfud_get_device_table()[0]; if (sfud_read(flash, offset, size, buf) != SFUD_SUCCESS) { return -1; } return size; } static int fal_flash_write(long offset, const uint8_t *buf, size_t size) { const sfud_flash *flash = sfud_get_device_table()[0]; if (sfud_write(flash, offset, size, buf) != SFUD_SUCCESS) { return -1; } return size; } static int fal_flash_erase(long offset, size_t size) { const sfud_flash *flash = sfud_get_device_table()[0]; if (sfud_erase(flash, offset, size) != SFUD_SUCCESS) { return -1; } return size; } const struct fal_flash_dev w25q64_flash = { .name = "norflash", .addr = 0, .len = 8 * 1024 * 1024, .blk_size = 4 * 1024, .ops = {fal_flash_init, fal_flash_read, fal_flash_write, fal_flash_erase}, .write_gran = 1, };blk_size是Flash的物理块大小,W25Q64最小擦除单位是4KB,所以这里填4096。write_gran是写粒度,有写命令能按字节写入的都填1,不能按字节写入的才填其他值。
接下来是分区表配置,在fal_cfg.h文件里定义。我这次划分了四个分区,分别用于App固件、参数、日志和OTA下载:
#define FAL_PART_TABLE \ { \ {FAL_PART_MAGIC_WORD, "app", "norflash", 0, 4 * 1024 * 1024, 0}, \ {FAL_PART_MAGIC_WORD, "param", "norflash", 4 * 1024 * 1024, 1 * 1024 * 1024, 0}, \ {FAL_PART_MAGIC_WORD, "log", "norflash", 5 * 1024 * 1024, 2 * 1024 * 1024, 0}, \ {FAL_PART_MAGIC_WORD, "download", "norflash", 7 * 1024 * 1024, 1 * 1024 * 1024, 0}, \ }这里最重要的规则就是地址对齐。分区表里的起始地址和长度必须按Flash的扇区大小对齐,W25Q64每个扇区4KB,所以起始地址和长度必须是4096的整数倍。我第一次划错的时候,把param分区的起始地址写成了4MB+1024,结果FAL读出来的数据全是乱的。
初始化顺序是:先sfud_init,再fal_init。fal_init内部会遍历Flash设备表,把SFUD拿到的Flash设备注册进FAL的管理范围,然后用打印日志的方式确认分区表解析成功。看到类似[FAL] Partition table load ok且各分区名称、大小与配置一致,基本就说明FAL这层已经通了。
提示:FAL本身不管理数据可靠性,它只是地址转换器。擦写均衡、掉电保护这些逻辑要放在上面的flashDB层去处理。不要指望FAL帮你实现“写不坏”的能力。
5. flashDB移植:从“读写Flash”进化到“存取数据”
5.1 KV模式还是TSDB模式,到底怎么选
flashDB的两种模式,对应两种截然不同的业务场景。
KV模式适合“设备的配置中心”。比如设备序列号、校准系数、服务器IP、用户参数配置,这些数据的特点是:总量不大、按键访问、更新频繁但不会无限增长。KV模式下,flashDB内部会管理扇区的状态,新值写入时不需要擦除整个扇区,而是在空闲区域追加新记录,旧记录通过标志位标记为废弃,等扇区写满了再做垃圾回收。这样既保证了掉电安全,又减少了擦写次数。
TSDB模式适合“带时间戳的数据流”。比如环境监测设备每小时记录一次温湿度,能耗采集设备每分钟记录一次电量数据。这种场景数据只会持续追加,很少修改,而且查询时通常按时间范围过滤。
区分方法其实很简单:数据是“改来改去”还是“只增不减”。如果是前者选KV,如果是后者选TSDB。如果你的需求两者都有,就分两个分区分别建库。我这次在param分区上建KV库,在log分区上建TSDB库,互不干扰。
5.2 移植步骤和基本用法
flashDB的移植工作主要集中在工程配置和初始化代码。首先要确保fdb_cfg.h里宏定义正确:
#define FDB_USING_KVDB #define FDB_USING_TSDB #define FDB_WRITE_GRAN 1 #define FDB_BIG_ENDIAN 0 #define FDB_SECTOR_SIZE 4096FDB_SECTOR_SIZE必须和Flash物理扇区大小一致,W25Q64就是4096。这个配置不匹配的话,flashDB在格式化和垃圾回收时会出现灾难性的错误,后面避坑部分会详细说。
KV库初始化的代码非常简洁:
#include <flashdb.h> struct fdb_kvdb kv_db; void kv_db_init(void) { fdb_err_t result = fdb_kvdb_init(&kv_db, "env_db", "param", NULL, NULL); if (result != FDB_NO_ERR) { printf("KVDB init failed: %d\r\n", result); } }注意fdb_kvdb_init的第三个参数传的就是FAL分区表里的分区名param,flashDB内部会调用FAL接口,拿着这个分区名去定位物理地址。
写入和读取也很直观。存一个整型参数:
struct fdb_blob blob; int sensor_offset = 88; // 写入 fdb_kv_set_blob(&kv_db, "sensor_offset", &sensor_offset, sizeof(sensor_offset)); // 读取 int read_value = 0; size_t len = fdb_kv_get_blob(&kv_db, "sensor_offset", &read_value, sizeof(read_value)); if (len > 0) { printf("sensor_offset = %d\r\n", read_value); }TSDB库的初始化和使用类似,只是多了一步迭代查询的配置:
struct fdb_tsdb ts_db; void tsdb_init(void) { fdb_err_t result = fdb_tsdb_init(&ts_db, "sensor_db", "log", NULL, NULL); if (result != FDB_NO_ERR) { printf("TSDB init failed: %d\r\n", result); } } void tsdb_append(int temperature, int humidity) { struct fdb_blob blob; struct sensor_data { int temp; int humi; } data = {temperature, humidity}; blob.data = &data; blob.size = sizeof(data); fdb_tsl_append(&ts_db, &blob); }查询就是遍历某个时间范围内的所有记录,把取出的数据交给回调函数处理:
void query_cb(fdb_tsl_t tsl, void *arg) { struct sensor_data data; fdb_blob_read((fdb_blob_t)(tsl->blob), &data, sizeof(data)); printf("temp=%d, humi=%d\r\n", data.temp, data.humi); } void tsdb_query(void) { fdb_time_t from = 0; fdb_time_t to = 0xFFFFFFFF; fdb_tsl_iter(&ts_db, from, to, query_cb, NULL); }到这里,整条链路就全通了:应用层调flashDB的API,flashDB通过FAL分区定位物理地址,FAL通过SFUD驱动操作W25Q64。
6. 实际踩过的坑与排查方法
6.1 高频问题速查表
整理一下这次移植过程中遇到的高频问题,直接按表格对照,节省排查时间:
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| SFUD扫描不到芯片,读ID失败 | SPI引脚、模式配置错误;CS引脚未拉低 | 用逻辑分析仪看时序,确认Mode 0下的波形;检查供电和去耦电容 |
| SFUD读到了错误ID | SPI速率过高,信号完整性不足 | 把SPI时钟分频调低,比如从最高速降到18MHz |
| FAL打印的分区表全部失败 | fal_cfg.h未正确包含;分区名和设备名不匹配 | 核对FAL_FLASH_DEV_TABLE和FAL_PART_TABLE的定义是否一致 |
| FAL读分区数据全0xFF | FAL底层erase或write的回调函数未正确转发到SFUD | 在fal_flash_write和fal_flash_erase里临时加打印,确认有没有被调用 |
| flashDB初始化失败,错误码为FDB_INIT_FAILED | FDB_SECTOR_SIZE和Flash物理扇区不一致 | 统一改成4096,重新擦除整个Flash后再试 |
| KV写入后立刻读取正常,重启后丢失 | 分区被固定为只读;FAL未正确初始化 | 检查分区表里是否配置了只读标志位,检查初始化顺序 |
| flashDB运行一段时间后所有扇区损坏 | 垃圾回收时断电或配置不对 | 确认FDB_WRITE_GRAN值,W25Q系列写粒度就是1;重新格式化Flash |
6.2 我在排障时的几点心得
第一,不要跳步。SFUD、FAL、flashDB三层,每一层都要单独验证通过再往上一层走。最忌讳的是三个一起移植完,然后跑一个demo,挂了之后从三个组件里找问题,排查面太大。
第二,善用擦除命令作为兜底。flashDB第一次使用前,如果之前这片Flash里有过旧数据,很容易初始化失败。最稳妥的办法是写一小段临时代码,通过SFUD直接把整个Flash擦掉,然后再初始化flashDB。注意flashDB初始化后第二次启动时不需要特殊处理,它自己会识别已格式化的扇区。
第三,printf的日志别看它简单,关键时刻能救命。SFUD和FAL都自带调试宏开关,建议在调试阶段把SFUD_DEBUG和FAL_DEBUG打开,里面每一行关键操作都有输出。打开方式是在Keil的C/C++选项卡里定义SFUD_USING_SFUD_DBG和FAL_DEBUG,再配一个重定向到串口的fputc函数。
7. 从参数到OTA:这套方案在真实项目里的应用
7.1 设备参数管理实现
这套三件套方案,第一个实际落地的场景是设备参数管理。设备里有一批出厂校准参数,需要在生产线上通过串口或上位机逐台写入,如果没有KV库,这种需求要么用固定的Flash地址硬存,要么自己写一套搬运逻辑。
用flashDB的KV模式之后,生产流程变得很简单。每个设备刷完固件后,上位机通过私有协议把校准参数发到MCU,MCU调用fdb_kv_set_blob逐条写入。启动时再通过fdb_kv_get_blob读出来,读不到就用默认值。这中间还顺手解决了一个历史痛点:参数结构体从v1升级到v2时,字段数量变了,但设备又不想清空重写,我直接把版本号作为一个key存进去,读取时判断版本号,旧版本参数做一次迁移就行。
7.2 日志采集与链路验证
另一个场景是数据日志。设备每隔5秒采样一次电流和电压,数据量不大但全天都在持续产生,如果存在裸的Flash区域,管理起来非常费劲。TSDB模式解决得很干净,每条记录带时间戳,写入时调用fdb_tsl_append,查询时按时间区间迭代,配合一个简单的导出命令,就能把历史数据通过串口打包发给上位机分析。
链路验证时,我故意做了一个掉电测试:写入一批日志后随机断电重启,再查询所有日志。flashDB在掉电保护方面做得很扎实,没有出现日志丢失或整个文件系统损坏的情况。这一点比很多自己写的落盘方案要放心得多。
7.3 扩展思路:和OTA配合使用
第三件事是给OTA升级预留了download分区。有了FAL的分区表,App固件和升级固件的地址被天然隔离。Bootloader里做固件校验时,只需要把模板裁判定在download分区,下载完校验通过后执行跳转,App里的分区表定义让新旧固件不会互相踩踏。这套架构从单机固件升级,到后续想加更多业务分区,都非常灵活。
最后说点实在的。FAL、SFUD、flashDB这套组合,看起来是三段代码拼接,实际上是一个完整的存储架构方法论。最值钱的不是那几百行移植代码,而是它逼着你在一开始就把存储这件事想清楚:底层怎么解耦、分区怎么划分、数据可靠性交给谁。如果你目前还在裸操作Flash写参数、攒日志,建议花一个下午把这套东西跑通,后面省下的调试时间绝对不止一个下午。再分享一个小技巧:把三件套的配套测试demo一次性固化到工程里,比如加一个“写入一批数据然后重启校验”的自检命令,以后每一次改板子、换芯片,都能在两分钟内确认存储链路是否正常。