4. SPI Flash 驱动封装:抽象层设计、接口定义

驱动封装这件事,说白了就是给底层硬件穿上「标准化外衣」。你想想看,LVGL 要读图片,它才不管你是 W25Q64 还是 MX25L128,它只想要一个统一的接口:给我地址,我把数据吐出来。

所以这一章的核心任务,就是设计一套抽象层,把 SPI Flash 的读写擦除操作包装成干净、可移植的 API。我在项目里吃过不少「驱动写死」的亏,后来学乖了——先定接口,再写实现,顺序不能乱。

4.1 为什么需要抽象层?

直接操作 SPI 寄存器当然也能用,但问题在于:

  • 硬件耦合太紧:换个 Flash 型号,代码得重写一半
  • LVGL 集成困难:LVGL 的图片解码层需要标准化的 read/write 回调
  • 调试维护成本高:每个项目都从零写一遍,浪费时间

抽象层的作用,就是在这两层之间加一个「翻译官」:

核心思想:上层(LVGL/应用)只调用抽象接口,下层(具体 Flash 驱动)实现这些接口。中间用结构体指针连接,松耦合、易替换。

SPI Flash 驱动抽象层架构 LVGL / 应用层 抽象接口层 (spi_flash_ops_t) read / write / erase / status / init / deinit W25Q64 驱动实现 MX25L128 驱动实现 SPI 硬件总线 + Flash 芯片

4.2 接口定义:spi_flash_ops_t 结构体

我个人习惯用函数指针结构体来定义驱动接口。这样上层代码只需要持有这个结构体的指针,具体实现可以后期注入。

/* spi_flash_ops.h */
#ifndef _SPI_FLASH_OPS_H_
#define _SPI_FLASH_OPS_H_

#include <stdint.h>

/* 状态码定义 */
typedef enum {
    FLASH_OK         = 0,
    FLASH_ERR_PARAM  = -1,
    FLASH_ERR_BUSY   = -2,
    FLASH_ERR_TIMEOUT = -3,
    FLASH_ERR_IO     = -4
} flash_status_t;

/* 驱动操作结构体 */
typedef struct {
    flash_status_t (*init)(void);
    flash_status_t (*deinit)(void);
    flash_status_t (*read)(uint32_t addr, uint8_t *buf, uint32_t len);
    flash_status_t (*write)(uint32_t addr, const uint8_t *buf, uint32_t len);
    flash_status_t (*erase)(uint32_t addr, uint32_t len);
    flash_status_t (*status)(uint8_t *sr);
    flash_status_t (*sync)(void);
} spi_flash_ops_t;

#endif /* _SPI_FLASH_OPS_H_ */

设计要点:

  • 返回值统一用 flash_status_t,上层只判断是否等于 FLASH_OK
  • sync() 用于等待内部操作完成,比如擦除后的忙检测
  • 地址参数用 uint32_t,覆盖最大 128Mbit 的 Flash 寻址

4.3 各接口的职责与实现要点

接口 功能 典型实现要点
init() 初始化 SPI 外设、GPIO、Flash 芯片 拉高 CS、配置 SPI 模式 0、读取芯片 ID 校验
deinit() 反初始化,释放资源 关闭 SPI 时钟、GPIO 恢复默认状态
read() 从指定地址读取 len 字节 发送 0x03 指令 + 3 字节地址,连续读取
write() 向指定地址写入 len 字节 先写使能 (0x06),再发 0x02 指令 + 地址 + 数据
erase() 擦除指定区域 4KB 扇区擦除 (0x20) 或 64KB 块擦除 (0xD8)
status() 读取状态寄存器 发送 0x05,读取 BUSY 位判断是否空闲
sync() 等待 Flash 内部操作完成 轮询 status() 直到 BUSY 位清零,带超时机制

4.4 避坑指南:我踩过的几个坑

接口定义看起来简单,但实际落地时有不少细节。我把自己踩过的坑列出来,你写代码时留个心眼:

  • 写之前必须使能:每次 write/erase 之前,必须先发 0x06(写使能指令)。我刚开始忘了这步,数据死活写不进去,查了半天才发现。
  • 擦除粒度不是字节:SPI Flash 的最小擦除单位是扇区(通常 4KB)。你不能只擦一个字节,必须整扇区擦。LVGL 图片更新时要注意对齐。
  • 读操作不需要使能:读指令 0x03 不需要先发写使能,直接发就行。这个和写操作不一样,别搞混。
  • 超时机制必须有:擦除操作可能耗时几百毫秒,sync() 里一定要加超时退出,否则芯片异常时程序会卡死。

重要提醒:SPI Flash 的写操作只能把 1 变成 0,不能把 0 变成 1。要把 0 变回 1,必须先擦除。所以更新数据时,通常是「读-改-擦-写」四步走。

4.5 实现示例:基于 W25Q64 的驱动封装

下面是一个简化的实现片段,展示如何把具体操作挂接到抽象接口上:

/* w25q64_drv.c */
#include "spi_flash_ops.h"
#include "spi_hw.h"   /* 底层 SPI 硬件操作 */

static flash_status_t w25q64_init(void) {
    uint8_t id[3];
    /* 读取芯片 ID 校验 */
    if (spi_flash_read_id(id) != 0) return FLASH_ERR_IO;
    if (id[0] != 0xEF || id[1] != 0x40) return FLASH_ERR_IO;
    return FLASH_OK;
}

static flash_status_t w25q64_read(uint32_t addr, uint8_t *buf, uint32_t len) {
    uint8_t cmd[4] = {0x03, (addr >> 16) & 0xFF, (addr >> 8) & 0xFF, addr & 0xFF};
    spi_cs_low();
    spi_transmit(cmd, 4);
    spi_receive(buf, len);
    spi_cs_high();
    return FLASH_OK;
}

/* 其他函数实现类似,此处省略 */

/* 导出操作结构体 */
const spi_flash_ops_t w25q64_ops = {
    .init   = w25q64_init,
    .deinit = w25q64_deinit,
    .read   = w25q64_read,
    .write  = w25q64_write,
    .erase  = w25q64_erase,
    .status = w25q64_status,
    .sync   = w25q64_sync
};

4.6 如何使用这套接口

上层代码只需要持有 spi_flash_ops_t 指针,调用时完全不用关心底层是什么芯片:

/* 应用层代码 */
#include "spi_flash_ops.h"

extern const spi_flash_ops_t w25q64_ops;

void flash_demo(void) {
    const spi_flash_ops_t *flash = &w25q64_ops;
    uint8_t buf[256];

    flash->init();
    flash->erase(0x000000, 4096);   /* 擦除第一个扇区 */
    flash->sync();                   /* 等待擦除完成 */
    flash->write(0x000000, buf, 256);
    flash->sync();
    flash->read(0x000000, buf, 256);
}

换芯片时:只需要把 w25q64_ops 换成另一个芯片的 mx25l128_ops,应用层代码一行都不用改。这就是抽象层的价值。

4.7 状态查询与错误处理

状态查询接口 status() 和 sync() 是容易被忽略但极其重要的部分。我见过不少项目,读写擦除都写了,唯独没做状态查询,结果芯片忙的时候发指令,数据全乱套。

建议的 sync 实现逻辑:

  1. 读取状态寄存器
  2. 检查 BUSY 位(bit 0)
  3. 如果为 1,延时后继续轮询
  4. 如果超时(比如 5 秒),返回 FLASH_ERR_TIMEOUT

嗯,这里要注意:不同 Flash 芯片的状态寄存器布局可能不同。W25Q64 的 BUSY 位在 bit 0,但有些芯片可能在 bit 1。封装时最好在驱动内部做适配,对外统一返回「忙/空闲」即可。