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 为例走一遍完整移植流程。

源码仓库:GitHub - armink/SFUD: An using JEDEC's SFDP standard serial (SPI) flash universal driver library | 一款使用 JEDEC SFDP 标准的串行 (SPI) Flash 通用驱动库 · GitHub

一、工程结构:需要加入哪些文件

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.hsfud_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(识别流程的产物——容量、擦除命令、擦除粒度都在这)、sfdpinit_okaddr_in_4_byte

  • 移植层要填的spiretry,这正是 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_readsfud_spi_port_initsfud_log_debugsfud_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;
}

几个容易踩的坑:

  1. CS 必须包住整个「写 + 读」。不能发完命令就抬 CS 再拉低去读,那样 Flash 会认为是两条独立命令,读回来全是 0xFF

  2. HAL 的 Size 参数是 uint16_t,一次最多 65535 字节,大块读写要分段——上面用 chunk 循环处理;

  3. 仅写(如擦除命令)时 read_size 传 0,仅读时同理,函数对两个方向的 0 长度都要兼容;

  4. 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_readsfud_port.c唯一的硬件收发通道,CS 包住先写后读
sfud_spi_port_initsfud_port.c把回调和重试参数挂到设备对象上
sfud_log_debug / sfud_log_infosfud_port.c日志输出,依赖 printf 重定向
spi_lock / spi_unlocksfud_port.c可选多任务并发保护,严禁用关中断实现
retry_delay_100ussfud_port.c可选忙等待轮询间隔,注意 volatile

回头看,SFUD 的移植量其实小得惊人:真正和硬件打交道的只有 spi_write_read 一个函数,其余都是注册和日志。识别芯片的脏活累活(SFDP 解析、芯片表比对)全部由库内部完成——这正是「通用驱动」该有的样子。

参考

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐