【学习笔记】STM32 移植 SFUD
STM32 移植 SFUD:从芯片识别原理到移植实战
前言
做 OTA 升级、参数存储、外挂文件系统(FatFs / LittleFS)时,几乎都绕不开外部 SPI NOR Flash。麻烦的地方在于:华邦 W25Q、兆易 GD25Q、旺宏 MX25L……各家芯片的命令集大同小异却又各有差别,每换一颗芯片就得改一遍驱动。
SFUD(Serial Flash Universal Driver)就是为解决这个问题而生的开源库(作者 armink),核心卖点就一个字:通用。
-
基于 JEDEC SFDP 标准,自动识别芯片容量、页大小、擦除粒度等参数;
-
老芯片读不到 SFDP?还有内置芯片信息表兜底;
-
对上提供统一 API:
sfud_read/sfud_write/sfud_erase; -
对下只需移植一个 SPI 收发接口,就能同时驱动任意多颗、不同厂家的 Flash。
本文分两部分:先讲清楚 SFUD 的核心对象、设备表和芯片识别机制,再以 STM32 HAL + W25Q128 为例走一遍完整移植流程。
一、工程结构:需要加入哪些文件
SFUD 的源码非常克制,全部加进工程也就 6 个文件:
sfud
├── inc
│ ├── sfud.h ← 对外 API
│ ├── sfud_def.h ← 核心数据结构(sfud_flash / sfud_spi)
│ ├── sfud_flash_def.h ← 内置芯片信息表
│ └── sfud_cfg.h ← 移植配置:设备在这里注册
├── src
│ ├── sfud.c ← 核心逻辑
│ └── sfud_sfdp.c ← SFDP 参数表读取与解析
└── port
└── sfud_port.c ← 移植文件:本文的主战场
整个移植只动两个文件:sfud_cfg.h 和 sfud_port.c,其余原封不动加进工程即可。
二、SFUD 的核心对象:sfud_flash
SFUD 里的一切都围绕 sfud_flash 这个结构体展开(定义在 sfud_def.h),每一颗 Flash 芯片对应一个对象:
/**
* serial flash device
*/
typedef struct {
char *name; /* 设备名称 */
size_t index; /* 设备在 flash_table 中的索引 */
sfud_flash_chip chip; /* 设备信息 */
sfud_spi spi; /* 设备所使用的 SPI 接口 */
bool init_ok; /* 初始化状态标志 */
bool addr_in_4_byte; /* 是否使用 4 字节地址寻址模式 */
struct { /* 向设备发送指令时等待响应过程的延时和尝试次数 */
void (*delay)(void); /* 设备操作延时等待函数 */
size_t times; /* 等待设备操作完成的重试次数 */
} retry;
void *user_data; /* 用户自定义数据 */
#ifdef SFUD_USING_QSPI /* 如果启用 QSPI 接口 */
sfud_qspi_read_cmd_format read_cmd_format; /* 用于支持 QSPI */
#endif
#ifdef SFUD_USING_SFDP /* 如果启用 SFDP 机制 */
sfud_sfdp sfdp; /* 存储设备的可发现参数 */
#endif
} sfud_flash, *sfud_flash_t;
从移植的视角看,这个结构体可以分成两半:
-
SFUD 自己填的:
chip(识别流程的产物——容量、擦除命令、擦除粒度都在这)、sfdp、init_ok、addr_in_4_byte; -
移植层要填的:
spi和retry,这正是sfud_spi_port_init()的工作。
其中 spi 成员是 sfud_spi 类型,里面就是移植要挂上去的几个函数指针:
typedef struct __sfud_spi {
char *name; /* SPI 设备名称,与设备表中 .spi.name 对应 */
sfud_err (*wr)(const struct __sfud_spi *spi, /* SPI 先写后读接口(移植核心) */
const uint8_t *write_buf, size_t write_size,
uint8_t *read_buf, size_t read_size);
#ifdef SFUD_USING_QSPI
sfud_err (*qspi_read)(const struct __sfud_spi *spi, uint32_t addr,
sfud_qspi_read_cmd_format *qspi_read_cmd_format,
uint8_t *read_buf, size_t read_size); /* QSPI 快速读接口 */
#endif
void (*lock)(const struct __sfud_spi *spi); /* 总线加锁(可选) */
void (*unlock)(const struct __sfud_spi *spi); /* 总线解锁(可选) */
void *user_data; /* 用户数据,一般放 SPI 句柄 */
} sfud_spi, *sfud_spi_t;
三、设备列表:sfud_cfg.h
SFUD 支持同时管理多颗 Flash,所有要用的设备都在 sfud_cfg.h 的设备表里注册。SFUD 内部会用这张表实例化出一个 sfud_flash 数组(sfud.c 里的 flash_table[]):
#ifndef _SFUD_CFG_H_
#define _SFUD_CFG_H_
#define SFUD_DEBUG_MODE
#define SFUD_USING_SFDP
#define SFUD_USING_FLASH_INFO_TABLE
enum {
SFUD_W25Q128_DEVICE_INDEX = 0, /* 定义设备的序列号 */
};
#define SFUD_FLASH_DEVICE_TABLE \
{ /* 定义设备的信息 */ \
[SFUD_W25Q128_DEVICE_INDEX] = {.name = "W25Q128", .spi.name = "SPI1"}, \
}
#define SFUD_USING_QSPI
#endif /* _SFUD_CFG_H_ */
几个配置项的含义:
| 配置项 | 作用 |
|---|---|
SFUD_DEBUG_MODE | 打开调试日志(sfud_log_debug 才会被调用) |
SFUD_USING_SFDP | 使能 SFDP 自动识别(识别途径一) |
SFUD_USING_FLASH_INFO_TABLE | 使能内置芯片表兜底(识别途径二) |
设备枚举 + SFUD_FLASH_DEVICE_TABLE | 注册设备:.name 是设备名(日志里显示),.spi.name 是总线名(传给移植层,多路 SPI 时用来区分) |
SFUD_USING_QSPI | 使用 QSPI 外设时才打开 |
设备的序列号(枚举值)后面有两个用处:sfud_get_device(index) 取设备对象,以及 sfud_spi_port_init() 里按索引分发初始化。
四、SFUD 是怎么识别芯片的
移植之前先搞清楚一个问题:我没告诉 SFUD 芯片多大、扇区多大,它怎么就能直接读写擦?答案是初始化时的三级识别机制:
sfud_init()
└─ 逐个设备 sfud_device_init()
├─ 0x9F 读 JEDEC ID(厂商 ID + 类型 ID + 容量 ID)
├─ 设备表里手动写死了 .chip 参数? ──是──► 直接采用,跳过识别
├─ 0x5A 读 SFDP 参数表成功? ──是──► 解析出容量/擦除粒度
├─ JEDEC ID 命中内置芯片表? ──是──► 采用表中参数
└─ 全部失败 ──────────────────────────► 初始化失败
途径一:SFDP 参数表——芯片自己报参数
JEDEC 标准(JESD216)规定,现代 NOR Flash 内部有一张只读参数表,叫 SFDP(Serial Flash Discoverable Parameters)。芯片出厂时就把自己的容量、页大小、支持哪几种擦除命令、每种擦多大,全都烧在这张表里了。

SFUD 初始化时用 0x5A 命令把这张表读出来(sfud_sfdp.c:337-352),然后解析出擦除信息(sfud_sfdp.c:307-315):

这就是 SFUD「通用」的底气:参数是芯片自己报的,驱动根本不需要认识这颗芯片。
途径二:JEDEC ID + 内置芯片表(SFDP 读不到时兜底)
老芯片没有 SFDP 表。这时 SFUD 用 0x9F 读 JEDEC ID——厂商 ID + 类型 ID + 容量 ID 共 3 字节(W25Q128 是 EF 40 18),拿去和内置的 25 款芯片表比对(sfud_flash_def.h:134),命中就直接用表里预置的参数。
途径三:手动指定(前两条都失败才需要)
在 sfud_cfg.h 的设备表里直接把 .chip 参数写死,SFUD 会跳过识别直接采用。只有非常冷门、两条自动识别路都走不通的芯片才用得上。
五、移植实战
硬件环境:W25Q128 挂在 SPI1 上,CS 用普通 GPIO 软件控制(不要用硬件 NSS——SFUD 要求一次 CS 周期内完成先写后读,必须自己掌控 CS 时序)。CubeMX 把 SPI1 配成全双工主模式、8bit、Mode 0(CPOL=0/CPHA=0,W25Q 系列 Mode 0/Mode 3 均可),再留一个 UART 给日志。
5.1 第一步:sfud_cfg.h 注册设备,按硬件裁剪 QSPI
先在设备表里注册自己的芯片,然后看一眼 SFUD_USING_QSPI:这个宏是给 QSPI 外设(四线快速读)准备的,打开后 sfud_spi 会多出 qspi_read 接口、sfud_flash 会多出 read_cmd_format 成员,移植工作量直接翻倍。用的是普通 SPI 就注释掉它:
#ifndef _SFUD_CFG_H_
#define _SFUD_CFG_H_
#define SFUD_DEBUG_MODE
#define SFUD_USING_SFDP
#define SFUD_USING_FLASH_INFO_TABLE
enum {
SFUD_W25Q128_DEVICE_INDEX = 0, /* 定义设备的序列号 */
};
#define SFUD_FLASH_DEVICE_TABLE \
{ /* 定义设备的信息 */ \
[SFUD_W25Q128_DEVICE_INDEX] = {.name = "W25Q128", .spi.name = "SPI1"}, \
}
/* #define SFUD_USING_QSPI */ /* 硬件没有 QSPI 功能,注释掉 */
#endif /* _SFUD_CFG_H_ */
5.2 第二步:sfud_port.c 移植层准备
sfud_port.c 开头先把包含、宏和日志缓冲准备好(数值按自己的硬件调整):
#include "sfud.h"
#include "spi.h" /* CubeMX 生成,提供 hspi1 */
#include <stdarg.h>
#include <stdio.h>
#define SFUD_SPI_MAX_CHUNK (65535U) /* HAL 单次传输上限(Size 参数为 uint16_t) */
#define SFUD_SPI_TIMEOUT_MS (1000U) /* HAL 传输超时(ms) */
#define SFUD_RETRY_TIMES (600000U) /* 100us x 600000 ≈ 60s 忙等待超时 */
#define SFUD_DELAY_100US_LOOPS (4200U) /* 168MHz 主频下约 100us 的空循环次数 */
#define SFUD_CS_LOW() HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET)
#define SFUD_CS_HIGH() HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET)
static char log_buf[256]; /* 日志格式化缓冲区 */
接下来实现四个必选项:spi_write_read、sfud_spi_port_init、sfud_log_debug、sfud_log_info。
5.3 spi_write_read:整个移植的核心
SFUD 所有的 Flash 操作——读 ID、读 SFDP、读状态寄存器、擦除、页编程、读数据——最终都汇到这一个函数。它的时序契约很简单:拉低 CS → 把 write_buf 发出去 → 把 read_size 字节收回来 → 拉高 CS,写和读必须在同一个 CS 周期内完成。
/**
* @brief SPI 先写后读接口(SFUD 核心回调)
*
* 拉低 CS 后先发送 write_size 字节命令/数据,再接收 read_size 字节,
* 最后拉高 CS。写、读长度超过 HAL 单次传输上限(65535 字节)时自动
* 分段传输。仅写或仅读时另一方向长度传 0 即可。
*
* @param[in] spi SFUD SPI 设备对象(user_data 为 &hspi1)
* @param[in] write_buf 待发送数据缓冲区(write_size 为 0 时可为 NULL)
* @param[in] write_size 发送字节数
* @param[out] read_buf 接收数据缓冲区(read_size 为 0 时可为 NULL)
* @param[in] read_size 接收字节数
*
* @retval SFUD_SUCCESS 传输成功
* @retval SFUD_ERR_NOT_FOUND spi 或其 user_data(SPI 句柄)为 NULL
* @retval SFUD_ERR_WRITE 写参数非法或 HAL 发送失败/超时
* @retval SFUD_ERR_READ 读参数非法或 HAL 接收失败/超时
*/
static sfud_err spi_write_read(const sfud_spi *spi, const uint8_t *write_buf,
size_t write_size, uint8_t *read_buf,
size_t read_size)
{
sfud_err result = SFUD_SUCCESS;
SPI_HandleTypeDef *hspi = NULL;
uint16_t chunk = 0U;
if (spi == NULL || spi->user_data == NULL)
{
return SFUD_ERR_NOT_FOUND;
}
if (write_size > 0U && write_buf == NULL)
{
return SFUD_ERR_WRITE;
}
if (read_size > 0U && read_buf == NULL)
{
return SFUD_ERR_READ;
}
hspi = (SPI_HandleTypeDef *)spi->user_data;
SFUD_CS_LOW();
/* 发送阶段:命令 + 地址 +(可选)待写数据 */
while (result == SFUD_SUCCESS && write_size > 0U)
{
chunk = (write_size > SFUD_SPI_MAX_CHUNK) ?
(uint16_t)SFUD_SPI_MAX_CHUNK : (uint16_t)write_size;
if (HAL_SPI_Transmit(hspi, (uint8_t *)write_buf, chunk,
SFUD_SPI_TIMEOUT_MS) != HAL_OK)
{
result = SFUD_ERR_WRITE;
}
write_buf += chunk;
write_size -= chunk;
}
/* 接收阶段:读状态寄存器 / 读数据 */
while (result == SFUD_SUCCESS && read_size > 0U)
{
chunk = (read_size > SFUD_SPI_MAX_CHUNK) ?
(uint16_t)SFUD_SPI_MAX_CHUNK : (uint16_t)read_size;
if (HAL_SPI_Receive(hspi, read_buf, chunk,
SFUD_SPI_TIMEOUT_MS) != HAL_OK)
{
result = SFUD_ERR_READ;
}
read_buf += chunk;
read_size -= chunk;
}
SFUD_CS_HIGH();
return result;
}
几个容易踩的坑:
-
CS 必须包住整个「写 + 读」。不能发完命令就抬 CS 再拉低去读,那样 Flash 会认为是两条独立命令,读回来全是
0xFF; -
HAL 的
Size参数是uint16_t,一次最多 65535 字节,大块读写要分段——上面用chunk循环处理; -
仅写(如擦除命令)时
read_size传 0,仅读时同理,函数对两个方向的 0 长度都要兼容; -
SPI 句柄从
spi->user_data里取,是sfud_spi_port_init注册进来的——这样同一个函数就能服务多颗挂在不同 SPI 上的 Flash。
5.4 sfud_spi_port_init:把回调挂到设备对象上
sfud_init() 初始化每个设备时都会回调这个函数。SPI 外设和 CS 引脚已经由 CubeMX 生成的代码初始化过了,这里只做一件事:按设备索引把读写函数、锁和重试参数挂到 flash 对象上。
/**
* @brief SFUD 移植层初始化(SFUD 核心回调)
*
* 由 sfud_init() / sfud_device_init() 调用。SPI 外设与 CS 引脚已由
* CubeMX 生成的 MX_SPI1_Init() / MX_GPIO_Init() 完成初始化,此处仅
* 注册读写、锁与重试参数。
*
* @param[in,out] flash SFUD Flash 设备对象
*
* @retval SFUD_SUCCESS 注册成功
* @retval SFUD_ERR_NOT_FOUND flash 为 NULL 或设备索引未注册
*
* @note 调用前必须先执行 MX_GPIO_Init() 与 MX_SPI1_Init()。
*/
sfud_err sfud_spi_port_init(sfud_flash *flash)
{
sfud_err result = SFUD_SUCCESS;
if (flash == NULL)
{
return SFUD_ERR_NOT_FOUND;
}
switch (flash->index)
{
case SFUD_W25Q128_DEVICE_INDEX:
{
flash->spi.wr = spi_write_read;
flash->spi.lock = spi_lock;
flash->spi.unlock = spi_unlock;
flash->spi.user_data = &hspi1;
/* 忙等待重试:每次约 100us,共约 60s 超时 */
flash->retry.delay = retry_delay_100us;
flash->retry.times = SFUD_RETRY_TIMES;
break;
}
default:
{
result = SFUD_ERR_NOT_FOUND;
break;
}
}
return result;
}
说明两点:
-
retry.times决定「等待擦除/写入完成」的超时上限。W25Q128 整片擦除手册标称最长 200s 量级、扇区擦除数百毫秒,按每次 100us 延时配 60 万次约等于 60s,覆盖扇区/块擦除足够,做整片擦除时记得加大; -
如果不打算实现可选项,
spi.lock/spi.unlock/retry.delay这几行直接删掉即可——SFUD 内部调用前都做了判空,为 NULL 就跳过。
5.5 sfud_log_debug / sfud_log_info:日志输出
SFUD 用 SFUD_DEBUG / SFUD_INFO 两个宏打日志,分别落到这两个函数。sfud_log_debug 只在 sfud_cfg.h 定义了 SFUD_DEBUG_MODE 时被调用,sfud_log_info 则无条件调用。
/**
* @brief 输出 SFUD 调试日志(带文件名与行号)
*
* 仅在 sfud_cfg.h 定义 SFUD_DEBUG_MODE 时被 SFUD_DEBUG 宏调用。
*
* @param[in] file 调用处源文件名
* @param[in] line 调用处行号
* @param[in] format printf 风格格式串
* @param[in] ... 可变参数
*
* @note 依赖 printf 重定向(fputc/_write 到 UART),未重定向前
* 标准库 printf 会走半主机导致程序卡死。
*/
void sfud_log_debug(const char *file, const long line, const char *format, ...)
{
va_list args;
va_start(args, format);
printf("[SFUD](%s:%ld) ", file, line);
vsnprintf(log_buf, sizeof(log_buf), format, args);
printf("%s\r\n", log_buf);
va_end(args);
}
/**
* @brief 输出 SFUD 常规信息日志
*
* 由 SFUD_INFO 宏无条件调用(不受 SFUD_DEBUG_MODE 控制)。
*
* @param[in] format printf 风格格式串
* @param[in] ... 可变参数
*
* @note 依赖 printf 重定向,同 sfud_log_debug。
*/
void sfud_log_info(const char *format, ...)
{
va_list args;
va_start(args, format);
printf("[SFUD]");
vsnprintf(log_buf, sizeof(log_buf), format, args);
printf("%s\r\n", log_buf);
va_end(args);
}
这里最大的坑不是函数本身,而是 printf 重定向:没重定向就调用标准库 printf,会走半主机(semihosting)机制直接卡死。Keil 下重定向 fputc(并勾选 MicroLIB 或做好半主机禁用处理),GCC 工具链则实现 _write():
/* Keil MDK 重定向示例;GCC 工具链请实现 _write() */
int fputc(int ch, FILE *f)
{
(void)f;
HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1U, 0xFFFFU);
return ch;
}
5.6 可选移植项
以下三个接口不实现也能跑(对应注册行删掉即可),但各有各的适用场景。
spi_lock / spi_unlock:裸机/单任务没影响,多任务并发才需要
/**
* @brief 锁定 SPI 总线
*
* 当前 Flash 仅由单一上下文访问(APP 的 OTA 任务 / Bootloader 主循环),
* 故为空实现。若日后 APP 中出现多任务并发访问,请在此接入 FreeRTOS
* 互斥锁(osMutexAcquire)。
*
* @param[in] spi SFUD SPI 设备对象
*
* @note 严禁用关中断(__disable_irq)实现本锁:SFUD 把「等待擦除完成」
* 也包在锁内,扇区擦除最长可达数百毫秒,关中断会导致 RTOS 心跳、
* UART、以太网全部停摆。
*/
static void spi_lock(const sfud_spi *spi)
{
(void)spi;
}
/**
* @brief 解锁 SPI 总线
*
* 与 spi_lock 配对,当前为空实现,说明见 spi_lock。
*
* @param[in] spi SFUD SPI 设备对象
*/
static void spi_unlock(const sfud_spi *spi)
{
(void)spi;
}
特别强调注释里那条 note:不要图省事用关中断当锁。SFUD 的加锁范围覆盖了「等待擦除完成」的整个忙等待过程,一次扇区擦除就是几百毫秒,这段时间关着中断,RTOS 心跳、UART、以太网全部停摆。要锁就用互斥量。
retry_delay_100us:忙等待轮询间隔
SFUD 等待擦除/写入完成时,会循环读状态寄存器的 BUSY 位,每轮之间调用一次 retry.delay。不注册也行(变成不带间隔的纯轮询),注册后总线压力小得多,超时时间也更好估算。
/**
* @brief 忙等待重试间隔延时,约 100us
*
* 供 SFUD 轮询 Flash 状态寄存器 BUSY 位时调用,
* 空循环次数按 168MHz 主频估算。
*
* @note delay 必须为 volatile,否则高优化等级下整个循环会被编译器删除。
*/
static void retry_delay_100us(void)
{
volatile uint32_t delay = SFUD_DELAY_100US_LOOPS;
while (delay-- > 0U)
{
}
}
注意 volatile 不能省:这是个没有任何副作用的空循环,-O2 以上编译器会把它整个优化掉,延时直接归零,超时时间就全乱了。
六、跑起来:验证移植
四个必选项写完,主函数里调用 sfud_init() 做个读写回环测试:
#include "sfud.h"
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_SPI1_Init();
MX_USART1_UART_Init();
if (sfud_init() == SFUD_SUCCESS)
{
const sfud_flash *flash = sfud_get_device(SFUD_W25Q128_DEVICE_INDEX);
uint8_t read_buf[16] = {0};
sfud_erase(flash, 0U, 4096U);
sfud_write(flash, 0U, 11U, (const uint8_t *)"Hello SFUD");
sfud_read(flash, 0U, sizeof(read_buf), read_buf);
printf("read: %s\r\n", read_buf);
}
while (1)
{
}
}
识别成功的话,串口会打出类似这样的日志(容量 16777216 字节 = 16MB,正是 W25Q128):
从这一刻起,读写擦全部走统一 API,芯片换成 GD25Q128、MX25L128 也不用改一行驱动——这就是 SFDP 识别机制带来的红利。
七、总结:移植清单
| 移植项 | 位置 | 必选 | 作用 |
|---|---|---|---|
| 设备表注册 | sfud_cfg.h | ✅ | 声明有哪些 Flash、名字、挂在哪条 SPI |
SFUD_USING_QSPI 裁剪 | sfud_cfg.h | ✅ | 没有 QSPI 功能就注释掉 |
spi_write_read | sfud_port.c | ✅ | 唯一的硬件收发通道,CS 包住先写后读 |
sfud_spi_port_init | sfud_port.c | ✅ | 把回调和重试参数挂到设备对象上 |
sfud_log_debug / sfud_log_info | sfud_port.c | ✅ | 日志输出,依赖 printf 重定向 |
spi_lock / spi_unlock | sfud_port.c | 可选 | 多任务并发保护,严禁用关中断实现 |
retry_delay_100us | sfud_port.c | 可选 | 忙等待轮询间隔,注意 volatile |
回头看,SFUD 的移植量其实小得惊人:真正和硬件打交道的只有 spi_write_read 一个函数,其余都是注册和日志。识别芯片的脏活累活(SFDP 解析、芯片表比对)全部由库内部完成——这正是「通用驱动」该有的样子。
参考
-
JEDEC JESD216:Serial Flash Discoverable Parameters (SFDP) 标准
更多推荐




所有评论(0)